Skip to content

fix(security): validate account names to prevent Numscript injection (EN-1200)#106

Open
flemzord wants to merge 8 commits into
mainfrom
fix/numscript-injection-validation
Open

fix(security): validate account names to prevent Numscript injection (EN-1200)#106
flemzord wants to merge 8 commits into
mainfrom
fix/numscript-injection-validation

Conversation

@flemzord

@flemzord flemzord commented Jun 11, 2026

Copy link
Copy Markdown
Member

Problem (Critical + High — C1, H2)

The credit/debit Numscript templates interpolate account names directly into the script body (@{{ .source }}). But:

  • type:"WALLET" subjects only checked Identifier != "" (neither the content nor Balance was validated);
  • balanceNameRegex was unanchored ([0-9A-Za-z_-]+), so MatchString accepted any string containing at least one valid character (e.g. balance:injected, x\n@world).

An identifier/balance/balance-name containing a newline and Numscript tokens could close the source block and inject arbitrary statements executed with the service's ledger credentials (e.g. send [USD *] (source = @world destination = @attacker:main)).

Fix

  • Anchor balanceNameRegex^[0-9A-Za-z_-]+$ (a name must be a single clean segment).
  • Validate both the identifier and balance of WALLET subjects via accounts.ValidateAddress (the same guarantee the already-safe ACCOUNT branch relies on).
  • Validate walletID before building debit/credit scripts.

Possible follow-up defense-in-depth: bind sources as account $sourceN variables instead of interpolating them — left out here to avoid destabilizing the exact-Numscript assertions in the existing tests.

Tests

  • TestBalancesCreate: names with an account separator and with whitespace + Numscript tokens → 400.
  • TestWalletsCredit: injection via the balance and via the identifier of a WALLET subject → 400.

From the in-depth repository review.

@flemzord flemzord requested a review from a team as a code owner June 11, 2026 07:50
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0c999a3c-e088-46ce-b6d0-96b792d88b0b

📥 Commits

Reviewing files that changed from the base of the PR and between 832ecd2 and 9c0eb06.

📒 Files selected for processing (9)
  • pkg/api/handler_balances_create_test.go
  • pkg/api/handler_wallets_credit_test.go
  • pkg/api/handler_wallets_debit_test.go
  • pkg/balance.go
  • pkg/chart.go
  • pkg/credit.go
  • pkg/debit.go
  • pkg/manager.go
  • pkg/subject.go

Walkthrough

The PR tightens balanceNameRegex to anchored full-string matching, introduces accountSegmentRegexp for single-segment wallet IDs, and adds Validate() checks in Credit, Debit, and Subject that reject names containing colons, whitespace, or Numscript metacharacters. Manager.Debit gains a runtime guard for stored balance names. A comment documents the dash-stripping aliasing hazard in Address. Tests cover all new rejection and acceptance paths.

Changes

Identifier Injection Validation

Layer / File(s) Summary
Anchored regex and accountSegmentRegexp
pkg/balance.go, pkg/subject.go
balanceNameRegex is changed from an unanchored to a full-string anchored pattern excluding :, whitespace, and Numscript metacharacters; accountSegmentRegexp is introduced in subject.go compiled from accounts.SegmentRegex to match exactly one ledger segment.
Validate() methods in Credit, Debit, and Subject
pkg/credit.go, pkg/debit.go, pkg/subject.go
CreditRequest.Validate() rejects invalid Balance names; new Credit.Validate() checks WalletID; Debit.Validate() checks both WalletID and each entry in Balances; Subject.Validate() checks Identifier and Balance for SubjectTypeWallet. All failures return newErrInvalidAccountName.
Manager runtime guard on stored balance names
pkg/manager.go
During Manager.Debit balance processing, each balance Name is validated against balanceNameRegex before being appended to transaction sources; invalid names return early with newErrInvalidAccountName.
Chart aliasing hazard documentation
pkg/chart.go
A comment block is inserted describing Address.String()'s dash-stripping behavior and how it can cause distinct dashed inputs to collide into the same ledger account.
Validation tests
pkg/api/handler_balances_create_test.go, pkg/api/handler_wallets_credit_test.go, pkg/api/handler_wallets_debit_test.go
Test tables are extended with colon-injection, whitespace/Numscript-token, dashed-name, and multi-segment walletID cases; TestWalletsCreditRejectsInvalidWalletID, TestWalletsDebitRejectsInvalidBalanceMetadata, and TestWalletsDebitRejectsInvalidWalletID are added, all asserting 400/ErrorCodeValidation and no transaction creation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 No sneaky colons shall slip through my gate,
No newlines or numscripts can alter my fate!
The regex is anchored, the segments are clean,
A dash in a name? That's perfectly fine, I've seen.
With guards in the manager and validates true,
This bunny's account names are safe — how about you?

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main security fix: validating account names to prevent Numscript injection, with the associated issue reference (EN-1200).
Description check ✅ Passed The description comprehensively explains the vulnerability, the fix, and implementation details, all directly related to the changeset's security improvements.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/numscript-injection-validation

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@flemzord flemzord changed the title fix(security): validate account names to prevent Numscript injection fix(security): validate account names to prevent Numscript injection (EN-1200) Jun 11, 2026
thierrycoopman
thierrycoopman previously approved these changes Jun 11, 2026

@flemzord flemzord left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revue inline: j’ai relevé deux trous de validation restants dans le durcissement des segments de compte.

Comment thread pkg/subject.go Outdated
Comment thread pkg/balance.go
@NumaryBot

Copy link
Copy Markdown
Contributor

🛑 Changes requested — multi-model review

The PR correctly anchors the balance name regex and adds injection validation for wallet subjects and wallet IDs, closing the primary Numscript injection vectors. However, two major issues require attention before merging. First, removing '-' from valid balance names goes beyond the stated anchoring fix: the dash character is not itself a Numscript injection vector, and the change silently breaks debit operations for any pre-existing wallet whose balances have dashed names (since names are re-validated against the new regex when read back from the ledger). This is a backwards-incompatible behavioral change that needs either a rollback to permitting dashes or a migration/compatibility plan. Second, WalletID validation for the Credit path is performed inside the manager method rather than inside Credit.Validate(), creating an inconsistency with the Debit path and a potential bypass for non-manager callers. Additionally, there are no handler-level tests exercising malicious walletIDs supplied via URL parameters, and the stated rationale for dash exclusion (Address.String() aliasing) lacks a confirming test or code reference.

🟠 [major] Removing '-' from balance names breaks backwards compatibility with existing data

pkg/balance.go:18 — reported by claude, gpt

The regex change from [0-9A-Za-z_-]+ to ^[0-9A-Za-z_]+$ drops support for dashes in balance names. This is more than an anchoring fix: any pre-existing balance whose name contains '-' will now fail validation in Manager.Debit (where names are read back from the ledger via BalanceFromAccount and then checked against the new regex). This silently breaks debit operations for wallets that already have dash-containing balances — a regression on existing data. The dash character is not itself a Numscript injection vector; the actual injection vectors are whitespace, newlines, and ':' separators. If dash aliasing is a separate concern, it requires an explicit migration/compatibility plan.

Suggestion: Either keep '-' in the allowed character set while still anchoring the regex (i.e. ^[0-9A-Za-z_-]+$) to fix the actual injection vectors, or treat invalid stored names defensively (e.g. skip/warn rather than hard-fail) so a single legacy balance doesn't block all debits. If '-' exclusion is intentional, split it into a separate breaking change with release notes and a data migration plan.

🟠 [major] WalletID validation for Credit lives outside Validate(), making it bypassable

pkg/manager.go:290 — reported by claude

The WalletID validation in the Credit flow is performed directly inside Manager.Credit via accountSegmentRegexp rather than inside Credit.Validate(). This means any other caller of Credit.Validate() (e.g. tests or future handlers) will not have the WalletID checked. This is an inconsistency with how Debit.Validate() handles its WalletID, and represents a potential bypass vector if the manager method is not the sole entry point in the future.

Suggestion: Move the accountSegmentRegexp.MatchString(credit.WalletID) check into Credit.Validate() (mirroring the pattern in Debit.Validate()) so that WalletID validation is centralized and cannot be bypassed by callers that only invoke Validate().

🟡 [minor] No handler-level tests for malicious URL wallet IDs

pkg/api/handler_wallets_credit_test.go — reported by gpt

The new tests cover balance names and WALLET subject identifiers, but there are no handler tests that exercise a malicious walletID coming in via the URL path (e.g. wallet:injected or a newline-containing value). This security-relevant validation path could regress without any focused test failing.

Suggestion: Add credit and debit handler tests using URL walletIDs containing account separators or Numscript tokens (e.g. wallet:injected, wallet%0A@world). Assert an HTTP 400 response and that no ledger transaction is created.

🟡 [minor] Dash-exclusion rationale for balance names is unverified and should have a confirming test

pkg/subject.go:60 — reported by claude

Subject.Validate() enforces stricter balance-name rules (no '-') compared to wallet identifiers, with the justification being that Address.String() strips dashes and causes aliasing. However, there is no test or code reference that actually demonstrates this aliasing behavior. Without such evidence, the stricter rule appears arbitrary and the security rationale is unverifiable.

Suggestion: Add a test demonstrating that Chart().GetBalanceAccount with a dashed name aliases to the undashed account, or add a code comment citing the specific ledger Address.String() behavior that causes the aliasing concern.

⚪ [nit] Mock transaction creator returns nil,nil which could mask nil-deref panics

pkg/api/handler_wallets_debit_test.go:467 — reported by claude

In TestWalletsDebitRejectsInvalidBalanceMetadata, the WithCreateTransaction mock returns (nil, nil). If the validation check is ever accidentally removed and the code proceeds to use the nil transaction result, the test would panic rather than produce a clean assertion failure, potentially masking regressions.

Suggestion: Return a minimal non-nil &shared.V2Transaction{} from the mock to avoid a potential nil-dereference panic obscuring the intended assertion failure.


Reviewed in parallel by claude (anthropic/claude-opus-4-8) and gpt (openai/gpt-5.5), then consolidated. This comment is updated on each push.

flemzord and others added 8 commits June 23, 2026 18:12
The credit/debit Numscript templates interpolate account names directly
into the script body (@{{ .source }}). WALLET subjects only checked that
the identifier was non-empty, and the balance-name regex was unanchored
(MatchString matches any substring), so a crafted identifier/balance/name
containing newlines or Numscript tokens could break out of the source
block and inject arbitrary statements executed with the service ledger
credentials.

- Anchor balanceNameRegex (^...$) so a name must be a single clean segment
- Validate WALLET subject identifier and balance via accounts.ValidateAddress
- Validate walletID before building debit/credit scripts

Adds regression tests for separator/whitespace/token names and for
injection via a WALLET source identifier and balance.
…nces

Address review feedback: accounts.ValidateAddress accepts a full multi-segment
address, so a WALLET subject with identifier/balance like "foo:bar" passed and
resolved to a nested account outside the balance model. Validate wallet IDs and
balance names against an anchored single-segment regex instead, keeping
ValidateAddress only for ACCOUNT subjects.

Documents the residual dash-stripping collision in Address.String() (kept as a
separate, migration-bearing change rather than forbidding dashes, which would
break legitimate dashed/UUID balance names).
Address.String() strips all '-' after joining segments, so inputs
differing only by dashes collapse to the same ledger account
("foo-bar" == "foobar"). This affects wallet IDs, balance names and
hold IDs alike and can silently make two distinct entities share one
account on create/get/debit/credit.

Constraint: the strip cannot be removed without changing the address of
every already-created wallet/balance/hold (needs a data migration)
Directive: do not remove the strip or relax segment validation without a
migration plan — the proper fix (stop stripping + migrate) is a separate ticket
Confidence: high
Scope-risk: narrow
The WalletID was validated ad-hoc inside Manager.Credit, so any caller
invoking Credit.Validate() directly (tests, future handlers) skipped the
check. Move it into Credit.Validate() — mirroring Debit.Validate() — so
the single-segment guard cannot be bypassed, and drop the duplicate check
from the manager.

Constraint: WalletID is interpolated as a chart segment, so it must be a
single anchored segment with no ':' or Numscript metacharacters
Rejected: keep the check only in the manager | bypassable by non-manager callers
Confidence: high
Scope-risk: narrow
Add credit and debit handler tests that send a WalletID via the URL path
spanning multiple account segments ("wallet:injected") or carrying
Numscript tokens ("wallet\n@world"), asserting a 400 and that no ledger
transaction is created. Also return a non-nil transaction from the
WithCreateTransaction mock in TestWalletsDebitRejectsInvalidBalanceMetadata
so a regression surfaces as an assertion failure rather than a nil-deref panic.

Confidence: high
Scope-risk: narrow
Re-allow '-' in balance names (^[0-9A-Za-z_-]+$), matching the charset
already used for wallet IDs. The anchoring remains the real injection fix
(no ':' separator, whitespace or Numscript tokens); dashes are not an
injection vector, and forbidding them regressed legitimate dashed/UUID
balance names — notably read-back validation in Manager.Debit would reject
pre-existing balances.

Dashes still alias under Address.String() (it strips '-', so "foo-bar"
and "foobar" collapse to one ledger account). This affects every
Chart-routed segment — wallet IDs, balance names, hold IDs — but not
SubjectTypeLedgerAccount identifiers, which bypass the strip. The comments
on balance.go/subject.go/chart.go now document that the collision is live,
not theoretical.

Constraint: dashes must stay valid — wallet/hold IDs are UUIDs and dashed
balance names already exist in production data
Rejected: forbid '-' to dodge aliasing | regresses existing data; aliasing
also affects wallet/hold IDs, so a regex tweak is not a real fix
Directive: the aliasing root cause (Address.String stripping '-') needs a
separate ticket (stop stripping + migrate) — do not "fix" it via validation
Confidence: high
Scope-risk: moderate
Flip the dash cases to success now that '-' is allowed: balance creation,
a dashed balance in a credit wallet source, a dashed credit destination,
and a dashed debit balance source all resolve and post a transaction.
TestWalletsDebitRejectsInvalidBalanceMetadata now stores a genuine
injection name ("injected\n@world") so it still proves read-back
validation rejects Numscript tokens rather than a now-valid dash.

Confidence: high
Scope-risk: narrow
@fguery fguery force-pushed the fix/numscript-injection-validation branch from 8800664 to 9c0eb06 Compare June 23, 2026 17:01

@NumaryBot NumaryBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 Changes requested — automated review

The new validation can reject the service's own UUID wallet IDs before chart normalization, breaking core credit/debit flows for normal wallets.

Comment thread pkg/subject.go
// individual chart segments, so — unlike a full ledger address — they must
// not contain ':' (which would resolve to a nested account outside the
// expected balance model and is the Numscript-injection vector).
var accountSegmentRegexp = regexp.MustCompile("^" + accounts.SegmentRegex + "$")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 [blocker] Allow generated UUID wallet IDs through validation

When credit/debit is called for a normal wallet created by this service, the wallet ID is a UUID containing -, but this regexp validates the raw ID as a ledger segment before Chart.String() gets a chance to normalize it by stripping dashes. Since ledger segments do not admit dashes, these new checks reject existing/generated wallet IDs and make credit/debit (and WALLET subjects using real wallet IDs) fail validation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker — accounts.SegmentRegex is [a-zA-Z0-9_-]+, so dashes are valid in a ledger segment and a UUID wallet ID passes accountSegmentRegexp without needing Address.String() to normalize it. Added TestWalletIDValidationAcceptsGeneratedUUID (b89d553) to prove generated UUIDs pass credit/debit/subject validation while :-bearing IDs are still rejected.

@fguery

fguery commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

@flemzord I allowed back the "-" in the various places; if we don't and we're using uuid we kinda have a problem ;)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants